SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
11 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
7.9 KB · 144 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { and, eq } from '@/lib/db';5import { db, collections, collectionItems } from '@/lib/db';6import { requireUser } from '@/lib/auth/session';7import { getAssetBrief, listCollections, loadItems } from '@/lib/account/queries';8import { valueItem } from '@/lib/account/portfolio';9import { getDisplay } from '@/lib/account/display';10import { deleteItemAction, moveItemAction, removeItemPhotoAction } from '@/lib/account/actions';11import { PageHeader, btnDanger, btnSecondary } from '@/components/account/page-header';12import { ValueSourceBadge } from '@/components/account/portfolio-widgets';13import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';14import { confidenceLabel, fmtPct } from '@/lib/format';15import { EditItemForm } from '../../add-item-form';16import { PhotoUploadForm } from './photo-form';1718export const metadata: Metadata = { title: 'Item', robots: { index: false } };1920export default async function ItemPage({ params }: { params: Promise<{ id: string; itemId: string }> }) {21  const { id, itemId } = await params;22  const u = await requireUser(`/collections/${id}/items/${itemId}`);23  const own = await db().select({ id: collectionItems.id }).from(collectionItems).innerJoin(collections, eq(collections.id, collectionItems.collectionId)).where(and(eq(collectionItems.id, itemId), eq(collections.id, id), eq(collections.userId, u.id))).limit(1);24  if (!own[0]) notFound();25  const loaded = (await loadItems([id])).find((i) => i.id === itemId);26  if (!loaded) notFound();27  const v = valueItem(loaded);28  const brief = await getAssetBrief(loaded.assetId);29  const d = await getDisplay();30  const others = (await listCollections(u.id)).filter((c) => c.collection.id !== id);3132  return (33    <>34      <nav className="mb-2 text-xs text-muted">35        <Link href="/collections" className="hover:text-fg">36          Collections37        </Link>{' '}38        /{' '}39        <Link href={`/collections/${id}`} className="hover:text-fg">40          Collection41        </Link>{' '}42        / <span className="text-fg">{loaded.title}</span>43      </nav>44      <PageHeader45        title={loaded.title}46        description={47          <>48            {loaded.categorySlug.replace(/_/g, ' ')} · <ValueSourceBadge item={v} />{' '}49            <Link href={`/asset/${loaded.assetSlug}`} className="underline-offset-4 hover:underline">50              Open asset page →51            </Link>52          </>53        }54        actions={55          <>56            {others.length ? (57              <form action={moveItemAction} className="flex items-center gap-1">58                <input type="hidden" name="itemId" value={itemId} />59                <select name="toCollectionId" className="h-9 rounded-md border border-border bg-elevated px-2 text-[13px]" defaultValue="">60                  <option value="" disabled>61                    Move to…62                  </option>63                  {others.map((o) => (64                    <option key={o.collection.id} value={o.collection.id}>65                      {o.collection.name}66                    </option>67                  ))}68                </select>69                <button className={btnSecondary}>Move</button>70              </form>71            ) : null}72            <form action={deleteItemAction}>73              <input type="hidden" name="itemId" value={itemId} />74              <button className={btnDanger}>Remove item</button>75            </form>76          </>77        }78      />79      <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-4">80        <Stat label="Current value" value={v.valueUsd === null ? '—' : d.money(v.valueUsd)} sub={v.valueUsd === null ? 'no market evidence yet' : v.confidence !== null ? `confidence ${confidenceLabel(v.confidence)}` : 'manual value'} />81        <Stat label="Cost" value={v.costUsd === null ? '—' : d.money(v.costUsd)} sub={loaded.purchasePriceNative !== null && loaded.acquiredCurrency && loaded.acquiredCurrency !== 'USD' ? `${loaded.purchasePriceNative} ${loaded.acquiredCurrency} at acquisition FX` : v.acquiredAt ? `acquired ${v.acquiredAt}` : 'no purchase price'} />82        <Stat label="Gain" value={<Delta value={v.gainPct} className="text-lg" />} sub={v.gainUsd === null ? '—' : d.money(v.gainUsd)} />83        <Stat label="Annualised" value={fmtPct(v.annualizedReturn)} sub={v.holdingDays !== null ? `${v.holdingDays} days held` : 'needs acquisition date'} />84      </div>85      {brief?.stats ? (86        <Card className="mb-5">87          <CardHeader title="Market context" subtitle="From the RareIndex asset record" />88          <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs sm:grid-cols-4">89            <dt className="text-subtle">Asset RIV</dt>90            <dd className="num">{brief.stats.rivUsd ? `${d.money(brief.stats.rivUsd)} (${brief.stats.rivSampleSize} sales)` : '—'}</dd>91            <dt className="text-subtle">Latest sale</dt>92            <dd className="num">{brief.stats.latestSaleUsd ? d.money(brief.stats.latestSaleUsd) : '—'}</dd>93            <dt className="text-subtle">30d / 1y</dt>94            <dd className="num">95              {fmtPct(brief.stats.change30d)} / {fmtPct(brief.stats.change1y)}96            </dd>97            <dt className="text-subtle">Liquidity · rarity</dt>98            <dd className="num">99              {brief.stats.liquidityScore ?? '—'} · {brief.stats.rarityScore ?? '—'}100            </dd>101            <dt className="text-subtle">Active listings</dt>102            <dd className="num">{brief.stats.activeListings}{brief.stats.minAskUsd ? ` · min ask ${d.money(brief.stats.minAskUsd)}` : ''}</dd>103            <dt className="text-subtle">Sales 30d</dt>104            <dd className="num">{brief.stats.sales30d}</dd>105          </dl>106        </Card>107      ) : null}108      <div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">109        <Card>110          <CardHeader title="Details" />111          <div className="p-4">112            <EditItemForm initial={{ itemId, asset: { id: loaded.assetId, title: loaded.title, categorySlug: loaded.categorySlug, year: null, heroImageUrl: loaded.heroImageUrl, rivUsd: loaded.assetRivUsd }, quantity: loaded.quantity, acquiredAt: loaded.acquiredAt, purchasePrice: loaded.purchasePriceNative, purchaseCurrency: loaded.acquiredCurrency, source: loaded.source, grader: loaded.grader, grade: loaded.grade, certificationNumber: loaded.certificationNumber, serial: loaded.serial, condition: loaded.condition, notes: loaded.notes, tags: loaded.tags, manualValueUsd: loaded.manualValueUsd, soldAt: loaded.soldAt, soldPriceUsd: loaded.soldPriceUsd ?? null }} />113          </div>114        </Card>115        <Card>116          <CardHeader title="Photos" subtitle={`${loaded.photos.length}/12`} />117          <div className="space-y-3 p-4">118            {loaded.photos.length ? (119              <ul className="grid grid-cols-3 gap-2">120                {loaded.photos.map((p) => (121                  <li key={p} className="group relative">122                    {/* eslint-disable-next-line @next/next/no-img-element */}123                    <img src={p} alt="" className="aspect-square w-full rounded-sm border border-border object-cover" />124                    <form action={removeItemPhotoAction} className="absolute right-1 top-1">125                      <input type="hidden" name="itemId" value={itemId} />126                      <input type="hidden" name="url" value={p} />127                      <button aria-label="Remove photo" className="rounded-sm bg-black/60 px-1.5 text-[10px] text-white opacity-0 group-hover:opacity-100">128                        ✕129                      </button>130                    </form>131                  </li>132                ))}133              </ul>134            ) : (135              <p className="text-xs text-subtle">No photos yet. Photos stay private unless the collection is public.</p>136            )}137            <PhotoUploadForm itemId={itemId} />138          </div>139        </Card>140      </div>141    </>142  );143}144